跳到主要内容

Java 并发编程-synchronized关键字

synchronized 性质

保证一段代码的原子性就是通过加锁和解锁实现的。Java 程序使用 synchronized 关键字对一个对象进行加锁:

synchronized(lock) {
n = n + 1;
}

1、使用 synchronized 解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为 synchronized 代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以 synchronized 会降低程序的执行效率。

2、在使用 synchronized 的时候,不必担心抛出异常。因为无论是否有异常,都会在 synchronized 结束处正确释放锁

synchronized (lock) {
throw new RuntimeException();

System.out.println("是否会执行这里"); // 不会到达这里
} // 无论有无异常,都会在此释放锁

3、加锁对象必须是同一个实例

构造方法可以使用 synchronized 吗?

构造方法不能使用 synchronized 关键字修饰。因为构造方法本身就属于线程安全的,不存在同步的构造方法一说。

synchronized 关键字的底层原理

synchronized 作用于「方法」或者「代码块」,保证被修饰的代码在同一时间只能被一个线程访问。

synchronized 关键字底层原理属于 JVM 层面。

实际上这个 synchronized 语句是由三个条指令组成的

n = n + 1;

拆能三个指令

ILOAD
IADD
ISTORE

如果不加锁,那两个线程执行上面的那个操作就会像下面这样

┌───────┐    ┌───────┐
│Thread1│ │Thread2│
└───┬───┘ └───┬───┘
│ │
│ILOAD (100) │
│ │ILOAD (100)
│ │IADD
│ │ISTORE (101)
│IADD │
│ISTORE (101)│
▼ ▼

可以看到线程1可能会在执行到一半时被时间片机制打断,又因为 Java内存模型中,线程读取的变量是先读取到工作内存中,等修改后再修改主空间的内容。所以这里就会导致两次 ADD操作变成一次

这种时候就应该加个锁,让线程1 执行时,线程2 无法执行

┌───────┐     ┌───────┐
│Thread1│ │Thread2│
└───┬───┘ └───┬───┘
│ │
│-- lock -- │
│ILOAD (100) │
│IADD │
│ISTORE (101) │
│-- unlock -- │
│ │-- lock --
│ │ILOAD (101)
│ │IADD
│ │ISTORE (102)
│ │-- unlock --
▼ ▼

通过加锁和解锁的操作,就能保证 3 条指令总是在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。

这种加锁和解锁之间的代码块称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。

对象锁是啥?

对象锁又名实例锁,类声明后,可以 new 出来很多的实例对象。这时候,每个实例在 JVM 中都有自己的引用地址和堆内存空间,这时候就认为这些实例都是独立的个体,很显然,在实例上加的锁和其他的实例就没有关系,互不影响了。

通常使用实例锁的方式有下面三种:

锁住实体里的非静态变量

非静态变量是实例自身变量,不会与其他实例共享,所以锁会在两个线程都调用这个对象的实例时生效,如果两个线程操作的对象不是同一个的话则无效

private final Object lock = new Object();

public void demo2() {
synchronized (lock) {
// do somthing
}
}

锁住 this 对象

this 指的是当前对象实例本身,所以,所有使用 synchronized(this) 方式的方法都共享同一把锁。

public void demo1() {
synchronized (this) {
...
}
}

直接锁非静态方法

public synchronized void demo1() {
...
}

synchronized 的类锁

类锁是加载类上的,而类信息是存在 JVM 方法区的,并且整个 JVM 只有一份,方法区又是所有线程共享的,所以同一时刻,只能有一个线程使用加了锁的方法或方法体,不管是不是同一个实例。

使用类锁的方式有如下方式:

锁住类中的静态变量

因为静态变量和类信息一样也是存在方法区的并且整个 JVM 只有一份,所以加在静态变量上可以达到类锁的目的。

private static final Object lock = new Object();

public void demo() {
synchronized (lock) {
// do somthing
}
}

直接在静态方法上加 synchronized

public static synchronized void demo1() {
...
}

锁住 xxx.class

public void demo2() {
synchronized (Temp.class) {
// do somthing
}
}

锁包装类的坑

不能使用包装类来当锁

例如这样使用 Integer 当锁则无法同步,最终的结果也不是 20000

public class Temp {

public static void main(String[] args) throws InterruptedException {
SyncDemo syncDemo = new SyncDemo();

Thread threadA = new Thread(syncDemo::add);
Thread threadB = new Thread(syncDemo::add);

threadA.start();
threadB.start();

Thread.sleep(1000);
System.out.println(syncDemo.getCount());
}

static class SyncDemo {
private Integer count = 0;

public void add() {
synchronized (count) {
for (int i = 0; i < 10000; i++) {
count++;
}
}
}

public Integer getCount() {
return count;
}
}
}

这是因为 Integer 这种包装类会自动拆装箱,之前学习数据结构时学过 Integer 有两种状态,当为 Integer 赋值数值在-128~127区间时,会从 Integer 中的一个 Integer[] 中获取一个缓存的 Integer 对象,而超出区间值得时候,每次都会 new 一个新的 Integer 对象

// 这里来看下 Integer的这段源码
@HotSpotIntrinsicCandidate
public static Integer valueOf(int i) {
// 这里的范围是 -128 to 127
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

综上可知,每次修改 Integer 的值,它的内存地址都不同了,那为啥每次比较都相同呢?

Integer num = 300;
System.out.println("identityHashCode:" + System.identityHashCode(num));
System.out.println("hashCode:" + num.hashCode());
// 再次赋值,实际上这个 num 指向的地址改变了
num = 300;
System.out.println("identityHashCode:" + System.identityHashCode(num));
System.out.println("hashCode:" + num.hashCode());

// 输出
// identityHashCode:1915910607
// hashCode:300
// identityHashCode:824909230
// hashCode:300

可以看见上面的内存地址是不一样的,但是 hashCode 确实一样的,实际就是 Integer 这些包装类重写了 hashCode

// 如下,直接返回值当作 hashCode,所以就算内存地址改变了,相同值的 Integer的 hashCode 一样是相同的
@Override
public int hashCode() {
return Integer.hashCode(value);
}

synchronized 锁的是对象,也就是 identityHashCode 所指向的内存地址中的对象实例(根据对象内存地址生成散列值),而 hashcode 输出的是值的散列值。所以为啥上述程序示例中,identityHashCode 每次不同,而 hashCode 输出的值却相同。

最后总结下,synchronized(Integer) 时,当值发生改变时,基本上每次锁住的都是不同的对象实例,想要保证线程安全,推荐使用 AtomicInteger 之类会更靠谱。

所以上面的那个例子,使用一个类对 Integer 包装一下就行了

public class Temp {

public static void main(String[] args) throws InterruptedException {
SyncDemo syncDemo = new SyncDemo();

Thread threadA = new Thread(syncDemo::add);
Thread threadB = new Thread(syncDemo::add);

threadA.start();
threadB.start();

Thread.sleep(1000);
System.out.println(syncDemo.getCount());
}

static class Count {
public Integer value = 0;
}

static class SyncDemo {
private Count count = new Count();

public void add() {
synchronized (count) {
for (int i = 0; i < 10000; i++) {
count.value++;
}
}
}

public Integer getCount() {
return count.value;
}
}
}

无需 synchronized 的场景

JVM 规范定义了几种原子操作:

  • 基本类型(long 和 double 除外)赋值,例如:int n = m
  • 引用类型赋值,例如:List<String> list = anotherList

long 和 double 是 64位数据,JVM 没有明确规定 64位赋值操作是不是一个原子操作,不过在 x64 平台的 JVM 是把 long 和double 的赋值作为原子操作实现的。

对 JVM 定义的单个原子操作不需要同步。

例如单条原子操作的语句不需要同步。

public void set(int m) {
synchronized(lock) {
this.value = m;
}
}

对引用类型赋值也属于原子操作,所以也无需同步。

public void set(String s) {
this.value = s;
}

但是,如果是多行赋值语句,就必须保证是同步操作,例如:

class Pair {
int first;
int last;

public void set(int first, int last) {
synchronized(this) {
this.first = first;
this.last = last;
}
}
}

有些时候,通过一些巧妙的转换,可以把非原子操作变为原子操作。例如,上述代码如果改造成:

class Pair {
int[] pair;

public void set(int first, int last) {
int[] ps = new int[] { first, last };
this.pair = ps;
}
}

就不再需要同步,因为 this.pair = ps 是引用赋值的原子操作。而语句:

int[] ps = new int[] { first, last };

这里的 ps 是方法内部定义的局部变量,每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步。

synchronized 的四种状态

参考资料 不可不说的Java“锁”事 参考资料 Java对象模型——Oop-Klass模型(一)

偏向锁、轻量级锁、重量级锁这三种锁是指锁的状态,专门针对 synchronized 的,这些个状态都是 JDK 对 Synchronized 的底层优化。在介绍这三种锁状态之前还需要介绍一些额外的知识

所以目前锁一共有 3 种状态,级别从低到高依次是:偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。

偏向锁、轻量级锁、重量级锁适用于不同的并发场景:

  • 偏向锁:在无竞争的情况下,把整个同步都消除掉,CAS操作都不做,且将来只有第一个申请锁的线程会使用锁。
  • 轻量级锁:无实际竞争,多个线程交替使用锁;允许短时间的锁竞争。在没有多线程竞争时,使用 CAS 操作来保证安全,相对重量级锁,减少操作系统互斥量带来的性能消耗。但是,如果存在锁竞争(CAS 失败),这时除了互斥量本身开销,还额外有 CAS 操作的开销,多次失败后升级为重量锁(这个次数看操作系统)
  • 重量级锁:有实际竞争,且锁竞争时间长。
    • 自旋锁:减少不必要的CPU上下文切换。在轻量级锁升级为重量级锁时,就使用了自旋加锁的方式
    • 锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

整体的锁状态升级流程如下(都是 JVM 内部自行决定的):

graph LR 无锁 --> 偏向锁 偏向锁 --> 轻量锁 轻量锁 --> 重量锁

综上,偏向锁通过对比 Mark Word 解决加锁问题,避免执行 CAS 操作。而轻量级锁是通过用 CAS 操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。

首先为什么 Synchronized 能实现线程同步?

在回答这个问题之前需要了解个重要的概念:“Java对象头”

前置知识:Java对象头

synchronized 是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在 Java 对象头里的,而 Java 对象头又是什么呢?

每个Java对象都有对象头。如果是非数组类型,则用 2个字宽来存储对象头,如果是数组,则会用 3个字宽来存储对象头。在 32位处理器中,一个字宽是32位;在64位虚拟机中,一个字宽是64位。对象头的内容如下表:

长度内容说明
32/64bitMark Word存储对象的hashCode或锁信息等
32/64bitClass Metadata Address存储到对象类型数据的指针
32/64bitArray length数组的长度(如果是数组)

以 Hotspot 虚拟机为例,Hotspot 的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。(注意,这里就是 Klass,它是源自 Oop-Klass 模型)

Mark Word的格式:

锁状态29 bit 或 61 bit1 bit 是否是偏向锁?2 bit 锁标志位
无锁001
偏向锁线程ID101
轻量级锁指向栈中锁记录的指针此时这一位不用于标识偏向锁00
重量级锁指向互斥量(重量级锁)的指针此时这一位不用于标识偏向锁10
GC标记此时这一位不用于标识偏向锁11

⭐:可以看到,当对象状态为偏向锁时,Mark Word 存储的是偏向的线程 ID;当状态为轻量级锁时,Mark Word存储的是指向线程栈中Lock Record 的指针;当状态为重量级锁时,Mark Word为指向堆中的 monitor 对象的指针。

通过上面的介绍,对 synchronized 的加锁机制以及相关知识有了一个了解,下面就是四种锁状态对应的的 Mark Word 内容,然后再分别讲解四种锁状态的思路以及特点:

锁状态存储内容存储内容
无锁对象的 hashCode、对象分代年龄、是否是偏向锁(0)01
偏向锁偏向线程 ID、偏向时间戳、对象分代年龄、是否是偏向锁(1)01
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10

偏向锁

Hotspot 的作者经过以往的研究发现大多数情况下 锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁

偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连 CAS 操作都不做了,提高了程序的运行性能

就是对锁置个变量,如果发现为 true,代表资源无竞争,则无需再走各种加锁/解锁流程。如果为 false,代表存在其他线程竞争资源,那么就会走后面的流程。

即:只有只有自己一个线程使用这个资源时会使用偏向锁,一旦出现其它线程也竞争这个锁,不管别的线程是不是取得锁,马上升级为轻量锁

偏向锁:实现原理

一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁的偏向的线程 ID。当下次该线程进入这个同步块时,会去检查锁的 Mark Word 里面是不是放的自己的线程 ID。

如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费 CAS 操作来加锁和解锁 ;如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用 CAS 来替换 Mark Word 里面的线程 ID 为新线程的 ID,这个时候要分两种情况:

1、成功,表示之前的线程不存在了, Mark Word 里面的线程 ID 为新线程的 ID,锁不会升级,仍然为偏向锁;

2、失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为0,并设置锁标志位为 00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。

线程竞争偏向锁的过程如下:

image.png

图中涉及到了 lock record 指针指向当前堆栈中的最近一个 lock record,是轻量级锁按照先来先服务的模式进行了轻量级锁的加锁。

即,只要发生冲突马上升级为轻量锁

偏向锁:撤销偏向锁

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁。

偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程看起来容易,实则开销还是很大的,大概的过程如下:

  1. 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
  2. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和 Mark Word,使其变成无锁状态。
  3. 唤醒被停止的线程,将当前锁升级成轻量级锁。

所以,如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭:

-XX:UseBiasedLocking=false

下面这个经典的图总结了偏向锁的获得和撤销:

轻量锁

多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM采用轻量级锁来避免线程的阻塞与唤醒。

但是在 CAS 尝试多次后(和JVM、操作系统相关)依旧无法取得锁后,那马上升级为重量锁

轻量锁:轻量级锁的加锁

如果一个线程获得锁的时候发现是轻量级锁,会把锁的 Mark Word 复制到自己的 Displaced Mark Word 里面。

JVM 会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为 Displaced Mark Word。当状态为轻量级锁时,Mark Word存储的是指向线程栈中 Lock Record 的指针

然后线程尝试用 CAS 将锁的 Mark Word 替换为指向锁记录(lock record)的指针。如果成功,当前线程获得锁,如果失败,表示 Mark Word 已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁,不会阻塞,从而提高性能。

自旋是需要消耗 CPU 的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费 CPU 资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。

但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁。

轻量锁:轻量级锁的释放

在释放锁时,当前线程会使用 CAS 操作将 Displaced Mark Word 的内容复制回锁的 Mark Word 里面。

如果没有发生竞争,那么这个复制的操作会成功。 如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么 CAS 操作会失败,此时会释放锁并唤醒被阻塞的线程。

一张图说明加锁和释放锁的过程:

image.png

这里失败是因为多次自旋都没有拿到锁

重量锁

重量级锁依赖于操作系统的互斥量(mutex) 实现的,而操作系统中线程间状态的转换需要相对比较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU。

前面说到,每一个对象都可以当做一个锁,当多个线程同时请求某个对象锁时,对象锁会设置几种状态用来区分请求的线程:

总结锁的升级流程

每一个线程在准备获取共享资源时:

第一步,检查 MarkWord 里面是不是放的自己的 ThreadId,如果是,表示当前线程是处于 “偏向锁” 。

第二步,如果 MarkWord 不是自己的 ThreadId,锁升级,这时候,用 CAS 来执行切换,新的线程根据 MarkWord 里面现有的 ThreadId,通知之前线程暂停,之前线程将 Markword 的内容置为空。

第三步,两个线程都把锁对象的 HashCode 复制到自己新建的用于存储锁的记录空间,接着开始通过 CAS 操作,把锁对象的 MarkWord 的内容修改为自己新建的记录空间的地址的方式竞争 MarkWord。

第四步,第三步中成功执行 CAS 的获得资源,失败的则进入自旋。

第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于轻量级锁的状态,如果自旋失败 。

第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。

各种锁的优缺点对比

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。如果线程间存在锁竞争,会带来额外的锁撤销的消耗。适用于只有一个线程访问同步块场景。
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度。如果始终得不到锁竞争的线程使用自旋会消耗CPU。追求响应时间。同步块执行速度非常快。
重量级锁线程竞争不使用自旋,不会消耗CPU。线程阻塞,响应时间缓慢。追求吞吐量。同步块执行时间较长。

synchronized 和 ReentrantLock 的区别

两者都是可重入锁

“可重入锁” 指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。

synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

synchronized 是依赖于 JVM 实现的,虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。

ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock()unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的

ReentrantLock 比 synchronized 增加了一些高级功能

相比 synchronized,ReentrantLock 增加了一些高级功能。主要来说主要有三点:

等待可中断:ReentrantLock 提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。

可实现公平锁:ReentrantLock 可以指定是公平锁还是非公平锁。而 synchronized 只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock 类的 ReentrantLock(boolean fair) 构造方法来制定是否是公平的。

可实现选择性通知(锁可以绑定多个条件):synchronized 关键字与 wait()notify()/notifyAll() 方法相结合可以实现等待/通知机制。ReentrantLock 类当然也可以实现,但是需要借助于 Condition 接口与 newCondition() 方法。

Condition是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个 Lock 对象中可以创建多个 Condition 实例(即对象监视器),线程对象可以注册在指定的 Condition 中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用 notify()/notifyAll() 方法进行通知时,被通知的线程是由 JVM 选择的,用 ReentrantLock 类结合 Condition 实例可以实现 “选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。而 synchronized 关键字就相当于整个 Lock 对象中只有一个 Condition 实例,所有的线程都注册在它一个身上。如果执行 notifyAll() 方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而 Condition 实例的 signalAll() 方法 只会唤醒注册在该 Condition 实例中的所有等待线程。

如果你想使用上述功能,那么选择 ReentrantLock 是一个不错的选择。不过性能已不是选择标准

Reference

参考资料 廖雪峰的官方网站 线程同步 参考资料 第九章 synchronized与锁 参考资料 2020最新Java并发进阶常见面试题总结 参考资料 使用ReentrantLock